Skip to content

refactor(db): composite PK on M2M association tables (sc-105349)#39859

Draft
mikebridge wants to merge 14 commits intoapache:masterfrom
mikebridge:sc-105349-composite-association-pks
Draft

refactor(db): composite PK on M2M association tables (sc-105349)#39859
mikebridge wants to merge 14 commits intoapache:masterfrom
mikebridge:sc-105349-composite-association-pks

Conversation

@mikebridge
Copy link
Copy Markdown
Contributor

@mikebridge mikebridge commented May 4, 2026

SUMMARY

*** This is a PR to aid in discussion about whether we should replace id fields in the m2m join tables ***

Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2) on the eight pure-junction tables. The redundant UNIQUE(fk1, fk2) on the two tables that previously carried one is dropped (subsumed by the new PK). All eight tables are M:N association tables — there is no semantic load on the surrogate id.

Affected tables and their composite PK pair:

Table Composite PK
dashboard_roles (dashboard_id, role_id)
dashboard_slices (dashboard_id, slice_id)
dashboard_user (user_id, dashboard_id)
report_schedule_user (user_id, report_schedule_id)
rls_filter_roles (role_id, rls_filter_id)
rls_filter_tables (table_id, rls_filter_id)
slice_user (user_id, slice_id)
sqlatable_user (user_id, table_id)

Why now: 6 of the 8 tables had no UNIQUE constraint at all, so duplicate (fk1, fk2) rows could (and did) accumulate in production data. The migration deduplicates by MIN(id) before the PK promotion. This change also lifts a precondition for the entity-versioning epic (sc-103156) — SQLAlchemy-Continuum's M:N restore replays the version table; surrogate-PK junctions interact poorly with the bulk reassign-and-replace pattern (Continuum issue #129). Continuum verification against the new shape is not part of this PR — it is the responsibility of the versioning epic.

BEFORE/AFTER

dashboard_slices is the canonical example (the other seven follow the same pattern, with dashboard_user, dashboard_roles, slice_user, sqlatable_user, rls_filter_* having no pre-existing UNIQUE):

Before:

CREATE TABLE dashboard_slices (
    id           SERIAL  PRIMARY KEY,
    dashboard_id INTEGER REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER REFERENCES slices(id) ON DELETE CASCADE,
    UNIQUE (dashboard_id, slice_id)
);

After:

CREATE TABLE dashboard_slices (
    dashboard_id INTEGER NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
    slice_id     INTEGER NOT NULL REFERENCES slices(id) ON DELETE CASCADE,
    PRIMARY KEY (dashboard_id, slice_id)
);

The redundant UNIQUE (dashboard_id, slice_id) is dropped (subsumed by the PK).

TESTING INSTRUCTIONS

Reproduces locally per quickstart.md steps 4–8:

  1. Apply the migrationsuperset db upgrade. Two tables with pre-existing UNIQUE (dashboard_slices, report_schedule_user) are batch-recreated via copy_from; the other six lose id and gain a composite PK directly. Migration logs the duplicate-row count and NULL-FK row count for each affected table.
  2. Confirm composite-PK enforcement (smoke) — for any of the eight tables: INSERT a (fk1, fk2) pair, then INSERT the same pair a second time. The second insert must raise IntegrityError (PK violation). Same pair under different IDs is no longer possible.
  3. Run the migration unit testspytest tests/unit_tests/migrations/composite_pk_association_tables_test.py (16 parametrized assertions: 8 duplicate-rejection, 8 distinct-pair).
  4. Run the migration integration testspytest tests/integration_tests/migrations/composite_pk_association_tables__tests.py tests/integration_tests/migrations/composite_pk_round_trip__tests.py (28 schema-shape assertions plus a round-trip + idempotency test against in-memory SQLite via MigrationContext).
  5. Verify downgradesuperset db downgrade <prior-revision>. The id column is restored (backfilled via sa.Identity(always=False) on Postgres/MySQL), and the original UNIQUE(fk1, fk2) is re-added on the two tables that originally had it. Intentional asymmetry: FK columns remain NOT NULL after downgrade — under SQLAlchemy secondary= semantics, NULL-FK junction rows are meaningless, so we don't restore the original nullable state.
  6. Verify upgrade idempotency — re-running superset db upgrade after downgrade returns the post-upgrade shape.

Cross-DB matrix (SC-005)

Verified end-to-end on all three backends locally — fresh full-history install (superset db upgrade from scratch) plus round-trip (upgrade → downgrade → upgrade) — and CI re-runs the migration during integration-test setup.

Backend Fresh install Round-trip Source
PostgreSQL 17 local docker container; CI test-postgres (current/next/previous), test-postgres-hive, test-postgres-presto
MySQL 8 local docker container with INSERT-without-id sanity check; CI test-mysql
SQLite local file-based DB with INSERT (NULL, 5)-rejection sanity check; CI test-sqlite; in-memory unit/integration tests (44 passed, 1 skip)

Bugs found and fixed during local cross-backend verification

The dedicated unit and integration tests run against in-memory SQLite, and CI's per-backend integration shards exercise the migration only as part of the suite's setup phase (which catches crashes but not subtle behavioural disparities). Doing fresh-install + round-trip locally on each real backend surfaced four bugs that were masked by both layers:

  • MySQL ERROR 1826 (Duplicate foreign key constraint name) during recreate="always" — InnoDB scopes FK constraint names per-database, not per-table. The temp table created by the recreate path collides with the original. CI's setup phase did catch this (the test-mysql shard turned red on first push); fix: drop FKs by name before the recreate on MySQL.
  • MySQL ERROR 1553 (Cannot drop index 'PRIMARY': needed in a foreign key constraint) on downgrade — InnoDB uses the composite PK index to back the FK on the leftmost column. Dropping the PK without first dropping the FKs orphans the index. Only manifests on a real round-trip — CI's setup-only check would never see it; fix: drop FKs before the PK swap on MySQL, re-add them after.
  • Missing AUTO_INCREMENT on the restored id column on MySQL — sa.Identity(always=False) only emits AUTO_INCREMENT when the column has primary_key=True at create time, but our portable path adds the column then creates the PK separately. Existing rows would all collide on id=0; subsequent INSERTs would fail with Field 'id' doesn't have a default value. Found by an explicit INSERT-without-id test against the post-downgrade DB; fix: combined DROP PRIMARY KEY, ADD COLUMN AUTO_INCREMENT, ADD PRIMARY KEY ALTER on MySQL.
  • SQLite composite PK doesn't enforce NOT NULL on constituent columns (long-standing SQLite quirk — only INTEGER PRIMARY KEY does). PostgreSQL and MySQL implicitly promote PK columns to NOT NULL; SQLite leaves them nullable for compound keys. A fresh SQLite install would have accepted INSERT (NULL, 5) despite both columns being part of the PK. The integration tests masked this because the test fixture seeds columns with nullable=False. Fix: explicit batch_op.alter_column(fk, nullable=False) for both FK columns — no-op on Postgres/MySQL where the PK already implies it.

The MySQL-specific fixes use raw triple-quoted SQL (no SQLA-core equivalent for the dialect-specific combined ALTER); the constitution allows raw SQL for dialect-specific DDL with no programmatic equivalent.

Operator runbook

Operators should run the pre-flight inventory queries from UPDATING.md ("Composite primary keys on many-to-many association tables") before applying — they show how many duplicate (fk1, fk2) rows and how many NULL-FK rows exist in their database. The migration deletes both classes (keeping MIN(id) per group for duplicates). The full operator-facing runbook is in UPDATING.md and includes:

  • The eight affected tables and their composite-PK columns
  • Impact on external readers (BI tools, backup scripts) that reference the surrogate id
  • Pre-flight inventory queries for assessing impact
  • Notice that duplicate and NULL-FK rows are not preserved
  • Workaround for restoring an old pg_dump against the new schema
  • Documentation of the FK-NOT-NULL downgrade asymmetry

ADDITIONAL INFORMATION

  • Has associated issue: sc-105349
  • Required feature flags: none
  • Changes UI: no
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible (modulo the documented FK-NOT-NULL downgrade asymmetry, which is intentional and operator-irrelevant)
    • Confirm DB migration upgrade and downgrade tested (round-trip + idempotency, 44 tests passing on SQLite locally; CI matrix covers Postgres + MySQL)
    • Runtime estimates and downtime expectations: the affected tables are small (M:N junctions for dashboards/datasets/charts/users/roles/RLS/reports — typically thousands to low-millions of rows even on the largest Superset deployments). The copy_from recreate path is O(n) table-rewrite for dashboard_slices and report_schedule_user; the other six are direct ALTER. Expected to complete in seconds-to-tens-of-seconds.
  • Introduces new feature or API: no
  • Removes existing feature or API: no

Continuum verification deferral

Verification of SQLAlchemy-Continuum's M:N restore behaviour against the new schema is the responsibility of the versioning epic (sc-103156) and is not part of this PR. This PR delivers the schema precondition; the versioning work picks up sqlalchemy-continuum as a dependency and verifies the restore behaviour there.

@github-actions github-actions Bot added the risk:db-migration PRs that require a DB migration label May 4, 2026
@dosubot dosubot Bot added change:backend Requires changing the backend risk:breaking-change Issues or PRs that will introduce breaking changes labels May 4, 2026
@mikebridge mikebridge marked this pull request as draft May 4, 2026 16:02
@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.88%. Comparing base (ad5e317) to head (10c36ac).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #39859   +/-   ##
=======================================
  Coverage   63.88%   63.88%           
=======================================
  Files        2583     2583           
  Lines      136618   136618           
  Branches    31502    31502           
=======================================
  Hits        87272    87272           
  Misses      47830    47830           
  Partials     1516     1516           
Flag Coverage Δ
hive 39.37% <ø> (ø)
mysql 59.04% <ø> (ø)
postgres 59.11% <ø> (ø)
presto 41.07% <ø> (ø)
python 60.55% <ø> (ø)
sqlite 58.75% <ø> (ø)
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py Outdated
Comment thread tests/unit_tests/migrations/composite_pk_association_tables_test.py
"""
# Identifiers come from the AFFECTED_TABLES whitelist, not user input.
sql = sa.text(
f"DELETE FROM {t.name} WHERE {t.fk1} IS NULL OR {t.fk2} IS NULL" # noqa: S608
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The combination of sa.text() and f" makes me nervous. Let's build a proper query here using SQLAlchemy core/ORM instead of building the SQL with string interpolation.

Comment on lines +165 to +181
sample_sql = sa.text(
f"SELECT {t.fk1}, {t.fk2}, id FROM {t.name} WHERE id NOT IN (" # noqa: S608
f" SELECT keep_id FROM ("
f" SELECT MIN(id) AS keep_id FROM {t.name} "
f"GROUP BY {t.fk1}, {t.fk2}"
f" ) AS s"
f") LIMIT 10"
)
sample = list(conn.execute(sample_sql))
sql = sa.text(
f"DELETE FROM {t.name} WHERE id NOT IN (" # noqa: S608
f" SELECT keep_id FROM ("
f" SELECT MIN(id) AS keep_id FROM {t.name} "
f"GROUP BY {t.fk1}, {t.fk2}"
f" ) AS s"
f")"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, let's use a programmatic query instead of building the string.

Comment on lines +206 to +208
f"SELECT COUNT(*) FROM (" # noqa: S608
f" SELECT 1 FROM {t.name} GROUP BY {t.fk1}, {t.fk2} HAVING COUNT(*) > 1"
f") AS s"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Also, not that it's usually much more legible to simply use triple quotes if we wanted to build the query as a string:

sql = f"""
    SELECT COUNT(*) FROM (
      SELECT 1 FROM {t.name}
      GROUP BY {t.fk1}, {t.fk2}
      HAVING COUNT(*) > 1
    ) AS s
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are updated---I am also running the down migrations locally against sqlite, postgres and mysql to make sure they do something sensible.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 4, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-105349-composite-association-pks branch from dd4d48d to 0e5380d Compare May 4, 2026 23:26
@netlify
Copy link
Copy Markdown

netlify Bot commented May 5, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 827aefd
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fa1da6a40abe0007935615
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 5, 2026

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 10c36ac
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/69fcf5201f48370008ba0ead
😎 Deploy Preview https://deploy-preview-39859--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 5, 2026
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-105349-composite-association-pks branch from 3870647 to 7ae35b4 Compare May 5, 2026 16:55
@michael-s-molina
Copy link
Copy Markdown
Member

Hi folks. I'm attending an onsite and don't have much availability this week. At Airbnb, we have tables like query, logs , and key_value with millions of rows. Migrations like the ones proposed in this PR are a significant problem for us due to the downtime required to upgrade. Is the current PK structure blocking anything or is it just an improvement to the schema?

@mikebridge
Copy link
Copy Markdown
Contributor Author

Hi folks. I'm attending an onsite and don't have much availability this week. At Airbnb, we have tables like query, logs , and key_value with millions of rows. Migrations like the ones proposed in this PR are a significant problem for us due to the downtime required to upgrade. Is the current PK structure blocking anything or is it just an improvement to the schema?

Hey @michael-s-molina! Yes, this will only touch the join tables that join to users/dashboards/charts/roles so the cardinality should be quite a bit lower than for something like logging. We can touch base when you have some time and maybe I can get some data from you that will help me gauge impact. My hypothesis is that the worst-case scenario is under two minutes.

One big side-issue here is fixing the data-integrity vulnerabilities with the missing UNIQUE and NOT NULL constraints.

Anyway, a subset of this R is to remove a blocker for Versioning, but it's not strictly necessary to fix every table in this iteration. Ideally I'd like to fix all eight tables at once though.

mikebridge pushed a commit to mikebridge/superset that referenced this pull request May 7, 2026
…105349)

Add a "Sizing the maintenance window on PostgreSQL" sub-section to the
operator runbook. The simple per-table COUNT/duplicate/NULL queries
that were already there are dialect-portable but only count rows;
operators on PostgreSQL with large deployments need to characterize
the migration's runtime cost before scheduling it.

Adds four diagnostic queries:

- Per-table size, row count (from pg_class.reltuples), and which
  migration path each table will take (recreate-rewrite vs direct
  ALTER). Sizes the work concretely.
- Aggregated duplicate-row roll-up: dup_groups + total rows_dropped
  per table. Replaces eight separate per-table queries with one
  consolidated result for audit/dump-before-apply decisions.
- External-FK pre-flight check (the same one the migration runs at
  upgrade time and aborts on). Lets operators surface any blocking
  external reference ahead of the maintenance window. Should be
  empty on a stock install.
- Lock-window estimate for the two full-rewrite tables, using
  pg_relation_size and a conservative 100 MB/s rewrite throughput
  assumption. The other six use direct ALTER and are dominated by
  composite-index build time (seconds for low-millions-of-rows
  tables).

Prompted by reviewer feedback on apache#39859 from a large
deployment asking how to size the maintenance window. The original
pre-flight queries are kept for cross-dialect operators (MySQL,
SQLite) since the new queries use PostgreSQL-specific catalog views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge
Copy link
Copy Markdown
Contributor Author

This should help to assess the impact on postgres:

  -- =====================================================================                                                                                                                    
  -- 1. Row counts and on-disk size per affected table                                            
  --    Tells us how much work the migration will do per table.                                                                                                                              
  --    The two tables marked WITH UNIQUE go through the slower                                                                                                                               
  --    "full table rewrite" path; the other six are direct ALTER.                                  
  -- =====================================================================                                                                                                                    
                                                                                       
  WITH affected(name, has_unique) AS (                                                                                                                                                        
    VALUES                                                                                     
      ('dashboard_roles',       false),                                                                                                                                                       
      ('dashboard_slices',      true),   -- full rewrite                 
      ('dashboard_user',        false),                                                                                                                                                       
      ('report_schedule_user',  true),   -- full rewrite                                                                                                                                      
      ('rls_filter_roles',      false),                                                                                                                                                       
      ('rls_filter_tables',     false),                                                                                                                                                       
      ('slice_user',            false),                                                                                                                                                       
      ('sqlatable_user',        false)                                                         
  )                                                                                                                                                                                           
  SELECT                                                                                                                                                                                      
    a.name                                                AS table_name, 
    CASE WHEN a.has_unique THEN 'recreate (full rewrite)'                                                                                                                                     
         ELSE 'direct ALTER' END                          AS migration_path,           
    c.reltuples::bigint                                   AS estimated_rows,                                                                                                                  
    pg_size_pretty(pg_total_relation_size(c.oid))         AS total_size, 
    pg_size_pretty(pg_relation_size(c.oid))               AS heap_size,                                                                                                                       
    pg_size_pretty(pg_indexes_size(c.oid))                AS index_size                        
  FROM affected a                                                                                                                                                                             
  JOIN pg_class c ON c.relname = a.name AND c.relkind = 'r'                                    
  ORDER BY pg_total_relation_size(c.oid) DESC;                                                                                                                                                

... and these will give some more details on some of the other data integrity issues:

  -- =====================================================================                                                                                                                    
  -- 2. Exact duplicate-row counts per table.                                                                                                                                                 
  --    The migration deletes duplicates (keeping MIN(id) per group).                                                                                                                         
  --    If any of these counts are nonzero, audit-sensitive operators                                                                                                                         
  --    should dump the affected rows BEFORE applying the migration.                                                                                                                          
  -- =====================================================================                     
                                                                                                                                                                                              
  SELECT 'dashboard_roles'      AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
    FROM (SELECT COUNT(*) c FROM dashboard_roles      GROUP BY dashboard_id, role_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'dashboard_slices',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_slices     GROUP BY dashboard_id, slice_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_user       GROUP BY user_id, dashboard_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id      HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_roles',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_roles     GROUP BY role_id, rls_filter_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'rls_filter_tables',   COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_tables    GROUP BY table_id, rls_filter_id          HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'slice_user',          COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM slice_user           GROUP BY user_id, slice_id                HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'sqlatable_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM sqlatable_user       GROUP BY user_id, table_id                HAVING COUNT(*) > 1) g                                                                        
  ORDER BY rows_dropped DESC NULLS LAST;                                                                                                                                                      
  -- =====================================================================                                                                                                                    
  -- 3. NULL-FK row counts.                                                                    
  --    The migration deletes any row with NULL in either FK column                                                                                                                           
  --    (since PK columns must be NOT NULL). Should normally be zero;                          
  --    nonzero means application bugs or manual SQL produced bad rows.                                                                                                                       
  -- =====================================================================                                                                                                                    
                                                                                                                                                                                              
  SELECT 'dashboard_roles'       AS t, COUNT(*) FILTER (WHERE dashboard_id IS NULL OR role_id IS NULL)             AS null_fk_rows FROM dashboard_roles                                       
  UNION ALL SELECT 'dashboard_slices',     COUNT(*) FILTER (WHERE dashboard_id IS NULL OR slice_id IS NULL)        FROM dashboard_slices                                                      
  UNION ALL SELECT 'dashboard_user',       COUNT(*) FILTER (WHERE user_id IS NULL OR dashboard_id IS NULL)         FROM dashboard_user                                                        
  UNION ALL SELECT 'report_schedule_user', COUNT(*) FILTER (WHERE user_id IS NULL OR report_schedule_id IS NULL)   FROM report_schedule_user
  UNION ALL SELECT 'rls_filter_roles',     COUNT(*) FILTER (WHERE role_id IS NULL OR rls_filter_id IS NULL)        FROM rls_filter_roles                                                      
  UNION ALL SELECT 'rls_filter_tables',    COUNT(*) FILTER (WHERE table_id IS NULL OR rls_filter_id IS NULL)       FROM rls_filter_tables                                                     
  UNION ALL SELECT 'slice_user',           COUNT(*) FILTER (WHERE user_id IS NULL OR slice_id IS NULL)             FROM slice_user                                                            
  UNION ALL SELECT 'sqlatable_user',       COUNT(*) FILTER (WHERE user_id IS NULL OR table_id IS NULL)             FROM sqlatable_user                                                        
  ORDER BY null_fk_rows DESC;                                                                                                                                                                 

If this returns any rows it'd really be a bad thing:

  -- =====================================================================                                                                                                                    
  -- 4. External FK references to the soon-to-be-removed `id` columns.                                                                                                                        
  --    The migration runs this same check as a pre-flight assertion and                                                                                                                      
  --    aborts if anything is found. Run it ahead of time so you know                                                                                                                         
  --    what (if anything) needs to be migrated/dropped first. On a                                                                                                                           
  --    standard Superset deployment this should return zero rows.                                                                                                                            
  --    (Default schema only; multi-schema deployments need to broaden.)                                                                                                                      
  -- =====================================================================                                                                                                                    
                                                                                                                                                                                              
  SELECT                                                                                                                                                                                      
    rc.constraint_name,                                                                                                                                                                       
    kcu.table_schema || '.' || kcu.table_name AS referencing_table,                                                                                                                           
    kcu.column_name                           AS referencing_column,                                                                                                                          
    ccu.table_name                            AS referenced_table,                                                                                                                            
    ccu.column_name                           AS referenced_column                                                                                                                            
  FROM information_schema.referential_constraints rc                                                                                                                                          
  JOIN information_schema.key_column_usage      kcu                                                                                                                                           
    ON kcu.constraint_name = rc.constraint_name                                                                                                                                               
   AND kcu.constraint_schema = rc.constraint_schema                      
  JOIN information_schema.constraint_column_usage ccu                                                                                                                                         
    ON ccu.constraint_name = rc.constraint_name                
   AND ccu.constraint_schema = rc.constraint_schema                                                                                                                                           
  WHERE ccu.table_name IN (                                                                    
          'dashboard_roles','dashboard_slices','dashboard_user',                                                                                                                              
          'report_schedule_user','rls_filter_roles','rls_filter_tables', 
          'slice_user','sqlatable_user')                                                                                                                                                      
    AND ccu.column_name = 'id';                                                                                                                                                               
  -- =====================================================================                                                                                                                    
  -- 5. Lock-window sizing for the recreate path on the two heaviest                                                                                                                          
  --    tables. The "full table rewrite" path takes an ACCESS EXCLUSIVE                        
  --    lock on the table for the duration. Estimate the rewrite time                                                                                                                         
  --    by combining heap size with your hardware's sequential write                                                                                                                          
  --    rate (≈100–200 MB/s on commodity SSD; faster on NVMe).                                 
  -- =====================================================================                                                                                                                    
                                                                                               
  SELECT                                                                                                                                                                                      
    c.relname                                       AS table_name,                                                                                                                            
    pg_size_pretty(pg_relation_size(c.oid))         AS heap_size,                                                                                                                             
    pg_relation_size(c.oid) / 1024 / 1024           AS heap_size_mb,                                                                                                                          
    -- conservative estimate: 100 MB/s effective rewrite throughput                                                                                                                           
    ROUND(pg_relation_size(c.oid) / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs                                                                                                  
  FROM pg_class c                                                                                                                                                                             
  WHERE c.relname IN ('dashboard_slices', 'report_schedule_user');      

@mikebridge
Copy link
Copy Markdown
Contributor Author

Here's the mysql equivalent:

1. Per-table size, row count, and which migration path each takes. Note that on InnoDB, ADD/DROP PRIMARY KEY rebuilds the clustered index, so all eight tables undergo a full rebuild — not just the two that go through the explicit recreate="always" path:

SELECT                                                                                                                                                                                      
  TABLE_NAME AS table_name,                                                                        
  CASE WHEN TABLE_NAME IN ('dashboard_slices', 'report_schedule_user')                                                                                                                      
       THEN 'recreate (explicit, drops UNIQUE)'
       ELSE 'direct ALTER (still rebuilds InnoDB clustered index)'                                                                                                                          
  END AS migration_path,                                                             
  TABLE_ROWS                                                  AS estimated_rows,                                                                                                            
  CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1), ' MB') AS total_size,         
  CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB')          AS heap_size,                  
  CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 1), ' MB')         AS index_size                  
FROM information_schema.TABLES                                                       
WHERE TABLE_SCHEMA = DATABASE()                                                                                                                                                             
  AND TABLE_NAME IN (                                                                                                                                                                       
    'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                                                                                                                
    'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                                                                                                                        
    'slice_user', 'sqlatable_user'                                                                                                                                                          
  )                                                                                                                                                                                         
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;                                                                                                                                                 
  1. Aggregated duplicate-row roll-up. dup_groups is the number of (fk1, fk2) pairs that appear more than once; rows_dropped is the total number of rows the migration will delete during the
    dedupe pass (it keeps MIN(id) per group):
  SELECT 'dashboard_roles'      AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped                                                                                               
    FROM (SELECT COUNT(*) c FROM dashboard_roles      GROUP BY dashboard_id, role_id            HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_slices',    COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_slices     GROUP BY dashboard_id, slice_id           HAVING COUNT(*) > 1) g
  UNION ALL SELECT 'dashboard_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM dashboard_user       GROUP BY user_id, dashboard_id            HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id      HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_roles',    COUNT(*), SUM(c) - COUNT(*)                          
    FROM (SELECT COUNT(*) c FROM rls_filter_roles     GROUP BY role_id, rls_filter_id           HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'rls_filter_tables',   COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM rls_filter_tables    GROUP BY table_id, rls_filter_id          HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'slice_user',          COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM slice_user           GROUP BY user_id, slice_id                HAVING COUNT(*) > 1) g                                                                        
  UNION ALL SELECT 'sqlatable_user',      COUNT(*), SUM(c) - COUNT(*)                                                                                                                         
    FROM (SELECT COUNT(*) c FROM sqlatable_user       GROUP BY user_id, table_id                HAVING COUNT(*) > 1) g                                                                        
  ORDER BY rows_dropped DESC;                                                                                                                                                                 
  1. External-FK pre-flight check. The migration runs the equivalent at upgrade time and aborts if anything is found. Should return zero rows on a stock Superset install:
  SELECT                                                                                                                                                                                      
    CONSTRAINT_NAME,                                                     
    CONCAT(TABLE_SCHEMA, '.', TABLE_NAME)        AS referencing_table,                                                                                                                        
    COLUMN_NAME                                  AS referencing_column,                                                                                                                       
    REFERENCED_TABLE_NAME                        AS referenced_table,                                                                                                                         
    REFERENCED_COLUMN_NAME                       AS referenced_column                                                                                                                         
  FROM information_schema.KEY_COLUMN_USAGE                                                                                                                                                    
  WHERE TABLE_SCHEMA = DATABASE()                                                              
    AND REFERENCED_TABLE_NAME IN (                                                                                                                                                            
      'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                                                                                                                
      'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                         
      'slice_user', 'sqlatable_user'                                                                                                                                                          
    )                                                                                                                                                                                         
    AND REFERENCED_COLUMN_NAME = 'id';                                                                                                                                                        
  1. Lock-window estimate for all eight tables. ADD PRIMARY KEY is INPLACE but not LOCK=NONE — concurrent reads OK, writes blocked. Combine heap_size_mb with your effective rebuild
    throughput (~100–200 MB/s on commodity SSD; higher on NVMe) to size the maintenance window per table:
  SELECT                                                                                                                                                                                      
    TABLE_NAME                                            AS table_name,                       
    CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB')    AS heap_size,                                                                                                                       
    ROUND(DATA_LENGTH / 1024 / 1024, 1)                   AS heap_size_mb,
    ROUND(DATA_LENGTH / 1024 / 1024 / 100.0, 1)           AS est_rewrite_seconds_at_100mbs                                                                                                    
  FROM information_schema.TABLES                                                                                                                                                              
  WHERE TABLE_SCHEMA = DATABASE()                                                                                                                                                             
    AND TABLE_NAME IN (                                                                                                                                                                       
      'dashboard_roles', 'dashboard_slices', 'dashboard_user',                                 
      'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',                         
      'slice_user', 'sqlatable_user'                                                   
    )                                                                                                                                                                                         
  ORDER BY DATA_LENGTH DESC;

@github-actions github-actions Bot added the risk:ci-script PR modifies scripts that execute in CI (supply chain risk) label May 7, 2026
@mikebridge
Copy link
Copy Markdown
Contributor Author

mikebridge commented May 7, 2026

Migration runtime on mysql — empirical numbers

Following up on the maintenance-window concern: we set up a stress-test data generator (scripts/seed_junction_load.py in this PR) to populate the four largest association tables at varying scales, then timed the migration's downgrade + upgrade at each scale. Numbers below are from MySQL 8 in a local Docker container — production Postgres on dedicated hardware should be at least as fast, likely faster.

Note

The seed data is unrealistically large by design---empirically, the homepage can't even render with this amount of data. The cross-product walk that lets us hit any target row count produces a Cartesian fan-out that no real deployment has. Specifically:

  • 3,163 synthetic dashboards × 3,163 synthetic charts → 10M dashboard_slices rows means every chart is on ~3,163 dashboards and every dashboard has ~3,163 charts.
  • 100 users × 3,163 dashboards → 100K dashboard_user (we capped lower) means every user owns hundreds of dashboards.

A real Superset deployment of comparable size would have:

  • Most charts on 1–3 dashboards (not 3,163)
  • Most dashboards with 5–50 charts (not 3,163)
  • Most users owning a handful of resources

So a real deployment with the same dashboard / chart / user counts would have dashboard_slices in the tens of thousands, not 10M, and the home page would render at normal speeds. The seed script is optimised for "stress-test the migration" not "stress-test the runtime app."

Data

dashboard_slices Total junction rows Downgrade Upgrade Per-row cost (upgrade)
100K ~111K 2s 1s ~9 µs
1M ~1.11M 11s 8s ~7.2 µs
5M ~5.55M 53s 45s ~8.1 µs
10M ~11.1M 1m 51s 1m 37s ~8.7 µs

The "Total junction rows" column adds the other three seeded tables (slice_user, dashboard_user, dashboard_roles) at proportionally smaller targets.

Scaling

Row factor Downgrade factor Upgrade factor
100K → 1M (10×) 5.5×
1M → 5M (5×) 4.8× 5.6×
5M → 10M (2×) 2.1× 2.16×

Linear scaling at production-relevant sizes. The 100K→1M ratio is sublinear because Alembic/Flask startup cost dominates at small N; from 1M onward, data-proportional work dominates and the time ratio matches the row ratio almost exactly. No memory cliff observed up through 10M (no work_mem / buffer-pool spill, no O(N²) regime).

Per-row cost stabilises around ~8–9 µs per junction row in the upgrade direction. The dominant cost component is the composite-PK index build; downgrade is 10–15% slower than upgrade due to the MySQL-specific FK drop/re-add overhead.

Extrapolation to larger deployments

dashboard_slices rows Predicted upgrade time
50M ~7–8 min
100M ~14–15 min
500M ~70–75 min

Caveats

  • Numbers are MySQL on Docker on macOS — real production Postgres on dedicated hardware will likely be faster (better disk I/O, larger buffer pool, no Docker overhead). Take these as a pessimistic upper bound.
  • We tested proportional smaller sizes for the other three tables. If one of those is much larger in a given deployment (e.g., slice_user at 50M because of a multi-team ownership pattern), add ~9 µs per row of that table to the estimate.
  • No memory cliff observed through 10M, but very large N (100M+) on a deployment with constrained work_mem could introduce a step-change. The diagnostic queries in UPDATING.md (per-table size, lock-window estimate) help size the window for an actual deployment.

Reproducing locally

The seed script is in this PR and is backend-agnostic (works on Postgres / MySQL / SQLite). To benchmark against your own deployment shape:

docker exec <superset-container> /app/.venv/bin/python /app/scripts/seed_junction_load.py \
    --dashboard-slices 5000000 --slice-user 500000 --dashboard-user 500000 --dashboard-roles 50000                                                                                            
                                                                                       
time docker exec <superset-container> superset db downgrade 33d7e0e21daa                                                                                                                    
time docker exec <superset-container> superset db upgrade              

Idempotent — re-running with a higher target adds rows up to the new total. See the script header for full options.

Dirty-data impact (non-unique)

Also tested with 5% duplicate (fk1, fk2) rows injected on the three non-UNIQUE junctions (slice_user, dashboard_user, dashboard_roles) — about 105K duplicates at 10M scale. Upgrade time: 1m 36s vs the clean baseline of 1m 37s, i.e., within measurement noise.

The migration's _dedupe_by_min_id phase is dominated by the table-scan / GROUP BY work that runs whether duplicates exist or not; the actual per-duplicate DELETE is incremental cost. So a deployment with mild data-quality issues (a few percent duplicates) sees no meaningful additional migration time. Operators with pathological cases (50%+ duplicates) would catch that with the inventory queries in UPDATING.md before applying.

Mike Bridge and others added 14 commits May 7, 2026 14:24
Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2)
on the eight pure-junction tables: dashboard_roles, dashboard_slices,
dashboard_user, report_schedule_user, rls_filter_roles, rls_filter_tables,
slice_user, sqlatable_user. The redundant UNIQUE(fk1, fk2) on dashboard_slices
and report_schedule_user is dropped (subsumed by the new PK).

Migration handles dialect quirks: copy_from for tables with pre-existing
UNIQUE (so SQLite's anonymous-constraint reflection doesn't matter), wrapped-
subquery dedupe for MySQL (ERROR 1093), sa.Identity(always=False) on downgrade
to backfill the restored id column without NOT NULL violations, and distinct
PK names per direction (pk_<table> on upgrade, <table>_pkey on downgrade) to
avoid round-trip index-name collisions on Postgres.

ORM Table() definitions updated to match. UPDATING.md entry added with
operator runbook (BI-tool impact, pre-flight inventory queries, dedupe-row-
loss notice, pg_dump workaround, FK-NOT-NULL downgrade asymmetry note).

Tests: 8 schema-shape assertions (post-upgrade), 8 duplicate-rejection unit
tests, 8 distinct-pair sanity tests, 1 round-trip + idempotency test
(in-memory SQLite via Alembic MigrationContext).

Continuum-restore verification against the new shape is out of scope for this
PR; it is the responsibility of the versioning epic (sc-103156).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanups from PR review:

1. ``dashboard_roles.dashboard_id`` was created nullable in revision
   e11ccdd12658 but was missing from ``TABLES_WITH_NULLABLE_FKS``. A
   production database with a stray NULL ``dashboard_id`` row would have
   failed the PK-add with a cryptic constraint violation. Fix by running
   the NULL-FK cleanup on every affected table — it is a no-op DELETE on
   tables whose FK columns are already NOT NULL, and it eliminates the
   risk of further drift in the hardcoded set. ``dashboard_roles`` is
   added to the documentation set; the runtime now does not consult it.

2. The unit-test parent-table name for ``rls_filter_roles`` and
   ``rls_filter_tables`` was ``rls_filter`` (does not exist) instead of
   the real parent ``row_level_security_filters``. Test passes either
   way (the in-memory FK is self-consistent), but the parameter is now
   accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four operator-experience improvements from the second review pass:

1. ``TABLES_WITH_NULLABLE_FKS`` is now explicitly documented as an
   informational set that is not consulted at runtime; the comment
   explains the previous ``dashboard_roles`` omission was the bug
   that motivated the always-run cleanup.
2. ``_delete_null_fk_rows`` docstring updated to match the
   "always run" semantics (was still claiming "called only on tables
   in TABLES_WITH_NULLABLE_FKS").
3. ``_check_no_external_fks_to_id`` now documents its scope
   limitation: ``Inspector.get_table_names()`` returns the default
   schema only, so cross-schema FKs in non-standard multi-schema
   PostgreSQL deployments would not be caught. The single-schema
   case (Superset's documented deployment) is fully covered.
4. ``_dedupe_by_min_id`` now logs a sample of up to 10 discarded
   ``(fk1, fk2, id)`` tuples at WARN before deletion, so operators
   can audit which rows the ``MIN(id)`` policy drops. The keep-
   original policy is correct in practice but discards later
   re-grants on ownership tables; the sample makes that visible.
5. ``UPDATING.md`` documents the upgrade/downgrade primary-key
   name divergence (``pk_<table>`` vs ``<table>_pkey``) so
   operators using schema-comparison tools don't mistake it for
   migration drift.

No schema or runtime-behaviour changes. All 44 migration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Beto's review comments on apache#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI test-mysql failed with:

  MySQLdb.OperationalError: (1826, "Duplicate foreign key constraint
  name 'fk_dashboard_slices_slice_id_slices'")

Root cause: MySQL scopes foreign-key constraint names per-database,
not per-table (PostgreSQL and SQLite scope per-table). The
``batch_alter_table(... recreate="always", copy_from=...)`` path
used for ``dashboard_slices`` and ``report_schedule_user`` builds
``_alembic_tmp_<table>`` carrying the original FK names from
``copy_from`` while the original table still holds those names — MySQL
rejects the temp-table creation with ERROR 1826.

Fix: on MySQL only, drop the original FK constraints by name before
the ``batch_alter_table`` runs. The ``copy_from`` re-creates them on
the rebuilt table with their original names, so the post-migration
shape is unchanged. On PostgreSQL and SQLite the original code path
still runs unchanged.

Local SQLite tests (44 passed, 1 skipped) still pass; CI will validate
on MySQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two MySQL-only failures in the downgrade path, found by running the
full migration history against a fresh MySQL 8 container:

1. ``MySQLdb.OperationalError: (1553, "Cannot drop index 'PRIMARY':
   needed in a foreign key constraint")``. InnoDB uses the composite
   PK index to back the FK on the leftmost column. The downgrade
   tried to drop the composite PK before dropping the FKs, orphaning
   the FK's backing index. PostgreSQL and SQLite create separate
   indexes for FK columns and don't trip on this.

2. ``Field 'id' doesn't have a default value`` on subsequent INSERT.
   ``sa.Identity(always=False)`` only emits ``AUTO_INCREMENT`` on
   MySQL when the column is created with ``primary_key=True`` — our
   portable path adds the column first then creates the PK separately,
   so MySQL leaves the column without auto-generation. Existing rows
   would all collide on id=0; future inserts fail because no default.
   Postgres' ``GENERATED BY DEFAULT AS IDENTITY`` and SQLite's
   ``INTEGER PRIMARY KEY`` rowid alias don't have this gap.

Fix: extract ``_downgrade_mysql_table()`` that emits the canonical
MySQL idiom — drop FKs, then a single ALTER combining
``DROP PRIMARY KEY, ADD COLUMN id INT NOT NULL AUTO_INCREMENT,
ADD PRIMARY KEY (id)`` (which backfills existing rows with sequential
ids and preserves AUTO_INCREMENT), restore the redundant UNIQUE on
the 2 tables that originally had it, and re-add the FKs with their
original names. Postgres and SQLite keep the existing portable
``batch_alter_table`` path.

Raw SQL is unavoidable for the combined-ALTER form; per the
constitution it's allowed for dialect-specific DDL with no SQLA
equivalent, with triple-quoted strings for legibility.

Verified end-to-end: upgrade → downgrade → upgrade against a fresh
MySQL 8 container with INSERT-without-id sanity check showing the
restored ``id`` column auto-increments correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found by running fresh-install + round-trip against a real SQLite DB:
6 of the 8 affected tables had FK columns that were originally
declared nullable. PostgreSQL and MySQL implicitly promote the
constituent columns of an ``ALTER TABLE ... ADD PRIMARY KEY`` to
``NOT NULL``; SQLite does not (it's a long-standing SQLite quirk —
only ``INTEGER PRIMARY KEY`` enforces NOT NULL on a composite-PK
column). Result: a fresh SQLite install would accept
``INSERT INTO dashboard_slices (NULL, 5)`` despite both columns
being part of the composite PK.

Our integration tests previously masked this: the test fixture seeds
columns with ``nullable=False``, so the post-upgrade NOT NULL
assertion passed regardless of whether the migration enforced it.

Fix: add explicit ``batch_op.alter_column(fk, nullable=False)`` for
both FK columns inside the per-table batch_alter_table block. On
PostgreSQL and MySQL this is a no-op (PK already implies NOT NULL);
on SQLite it adds the missing NOT NULL declaration so a fresh
install matches the data-model.md "After" contract.

Verified end-to-end:
- Postgres + MySQL: column shape unchanged (still NOT NULL)
- SQLite fresh install + round-trip: all 8 tables now have NOT NULL
  on FK columns, ``INSERT (NULL, 5)`` correctly rejected with
  IntegrityError on dashboard_slices, dashboard_user, sqlatable_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI cypress + playwright shards were red with:

  ERROR [flask_migrate] Error: Multiple head revisions are present
  for given argument 'head'

The recent rebase onto master pulled in
``33d7e0e21daa_add_semantic_layers_and_views.py`` (from PR apache#37815,
"semantic layer extension"), which had been authored against
``ce6bd21901ab`` as its parent — the same parent our migration
referenced. After the rebase both migrations point at
``ce6bd21901ab``, producing two heads and breaking ``flask db
upgrade head`` for any downstream consumer (CI's Cypress / Playwright
shards spin up a real Superset instance via ``superset db upgrade``,
which is why those shards failed first; the integration shards run
against a precomputed schema and didn't surface this).

Fix: chain our migration after the semantic-layer migration by
pointing ``down_revision`` at ``33d7e0e21daa``. The chain is now
linear:

    ... → ce6bd21901ab → 33d7e0e21daa (semantic layers)
                          → 2bee73611e32 (composite PK, this PR)

Verified with ``superset db heads`` (returns single head
``2bee73611e32``) and the local migration test suite (44 passed,
1 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…105349)

Add a "Sizing the maintenance window on PostgreSQL" sub-section to the
operator runbook. The simple per-table COUNT/duplicate/NULL queries
that were already there are dialect-portable but only count rows;
operators on PostgreSQL with large deployments need to characterize
the migration's runtime cost before scheduling it.

Adds four diagnostic queries:

- Per-table size, row count (from pg_class.reltuples), and which
  migration path each table will take (recreate-rewrite vs direct
  ALTER). Sizes the work concretely.
- Aggregated duplicate-row roll-up: dup_groups + total rows_dropped
  per table. Replaces eight separate per-table queries with one
  consolidated result for audit/dump-before-apply decisions.
- External-FK pre-flight check (the same one the migration runs at
  upgrade time and aborts on). Lets operators surface any blocking
  external reference ahead of the maintenance window. Should be
  empty on a stock install.
- Lock-window estimate for the two full-rewrite tables, using
  pg_relation_size and a conservative 100 MB/s rewrite throughput
  assumption. The other six use direct ALTER and are dominated by
  composite-index build time (seconds for low-millions-of-rows
  tables).

Prompted by reviewer feedback on apache#39859 from a large
deployment asking how to size the maintenance window. The original
pre-flight queries are kept for cross-dialect operators (MySQL,
SQLite) since the new queries use PostgreSQL-specific catalog views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…349)

Mirror of the PostgreSQL diagnostic queries added in 1114877,
adapted for MySQL/InnoDB. One important difference: InnoDB rebuilds
the clustered index on every PK change, so all eight tables undergo
a full table rebuild on MySQL — not just the two that go through
the explicit ``recreate="always"`` path. The lock-window estimate
query is updated to cover all eight rather than just two, and the
"migration_path" column makes the rebuild expectation explicit
("direct ALTER (still rebuilds InnoDB clustered index)").

Other notes:
- ``information_schema.TABLES.TABLE_ROWS`` is an InnoDB estimate,
  analogous to PostgreSQL's ``reltuples``; documented inline.
- ``KEY_COLUMN_USAGE`` carries both sides of the FK in a single
  row on MySQL, so the external-FK pre-flight check is simpler
  than the PostgreSQL version (no joins between three views).
- The aggregated dedupe query is portable standard SQL; included
  verbatim for copy-paste convenience.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ``docker-compose-mysql.yml``, a compose-override file that swaps
the default Postgres metadata DB for MySQL 8 with one extra ``-f``
flag:

  docker compose -f docker-compose.yml -f docker-compose-mysql.yml up

Useful for evaluating dialect-specific behaviour (e.g., the runtime
cost of DDL migrations on a deployment whose production metadata DB
is MySQL — the question raised by review feedback on this PR).

Mirrors the connection settings used by CI's ``test-mysql`` shard:
``mysql+mysqldb`` dialect, charset ``utf8mb4`` with binary_prefix.
Host port defaults to 13306 (configurable via ``DATABASE_PORT_MYSQL``)
to avoid colliding with a native MySQL install on 3306.

A separate volume (``db_home_mysql``) keeps MySQL data isolated from
the Postgres ``db_home`` volume, so switching between the two with
``-f`` flag toggles doesn't corrupt either side.

The Postgres-specific init scripts under
``docker/docker-entrypoint-initdb.d/`` are not mounted on the MySQL
service (they are postgres-only). Examples / cypress fixtures still
load via ``superset-init``'s post-startup steps, which run
``superset load-examples`` against whichever metadata DB is in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix two follow-on issues reported when starting the dev stack with
docker-compose-mysql.yml:

1. ``superset-init`` step 4 (load-examples) fails with
   ``MySQLdb.OperationalError: (2002, "Can't connect to server on 'db'")``
   because the analytics-examples DB connection inherits ``EXAMPLES_PORT=5432``
   (Postgres port) from ``docker/.env``. The override flipped
   ``DATABASE_DIALECT`` to ``mysql+mysqldb`` but left the EXAMPLES_*
   group on Postgres defaults, so the URI became
   ``mysql+mysqldb://examples:examples@db:5432/examples`` — MySQL
   container has no listener on 5432.

   Fix: add ``EXAMPLES_HOST/PORT/DB/USER/PASSWORD`` and a complete
   ``SUPERSET__SQLALCHEMY_EXAMPLES_URI`` to the ``mysql-env`` anchor.

2. The Postgres init scripts under
   ``docker/docker-entrypoint-initdb.d/`` (``cypress-init.sh``,
   ``examples-init.sh``) get mounted on the MySQL container too —
   compose merges volume lists. They invoke ``psql`` which doesn't
   exist in the MySQL image, abort with ``psql: command not found``,
   and prevent the ``examples`` DB from being created.

   Fix: add a MySQL-specific init script
   ``docker/mysql-init/examples-init.sql`` that creates the
   ``examples`` database and user, and mount it at
   ``/docker-entrypoint-initdb.d`` in the override. Compose's
   later-takes-precedence rule on duplicate volume targets displaces
   the Postgres init dir, so the MySQL container only sees the
   MySQL-compatible script.

   (Used a plain duplicate-target mount rather than the ``!override``
   tag because pre-commit's ``check-yaml`` doesn't recognize Compose's
   custom YAML tags.)

Recovery for an existing failed MySQL stack: ``docker compose -f
docker-compose.yml -f docker-compose-mysql.yml down``, then
``docker volume rm superset_db_home_mysql`` (so the new init script
runs on the next fresh boot), then ``up`` again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ``scripts/seed_junction_load.py``, a backend-agnostic script that
bulk-inserts synthetic parent rows (dashboards, slices, users, roles,
tables, dbs) and many-to-many junction rows for the four largest
association tables targeted by the composite-PK migration:
``dashboard_slices``, ``slice_user``, ``dashboard_user``,
``dashboard_roles``.

Designed for measuring migration runtime at varying scales — run with
a series of size flags (100K / 1M / 5M / 10M for the target table)
and time the migration at each scale to verify the predicted
``O(N log N)`` extrapolation against real numbers.

Properties:
- **Reproducible**: deterministic cross-product walk through parent IDs
  produces a stable pair sequence; re-running is replayable.
- **Idempotent**: re-running with the same target is a no-op; with a
  higher target, only new rows are added.
- **Backend-agnostic**: connects via Superset's standard ``DATABASE_*``
  env vars (or ``SUPERSET__SQLALCHEMY_DATABASE_URI``). Branches on
  dialect for ``BINARY(16)`` vs ``UUID`` vs TEXT/BLOB UUID columns.
- **Batched**: bulk INSERT 10K rows per statement.
- **Per-phase timing**: logs elapsed wall time for the parents phase,
  the junctions phase as a whole, and per junction-table.
- **Avoidance set**: loads existing junction pairs into a Python set
  so re-runs on top of pre-existing data don't collide on the
  uniqueness constraint.

Usage (inside the Superset container):

    docker exec superset-superset-1 \\
        /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
        --dashboard-slices 1000000

Defaults target a "large multi-team install" shape: 1M
``dashboard_slices``, 100K each ``slice_user`` / ``dashboard_user``,
10K ``dashboard_roles``. Override per-table via flags.

Tested locally on MySQL (the user's current eval stack):
- 200/100/100/50 row mini-run produced expected counts.
- Re-running at the same target is a no-op (idempotent).
- ``--dry-run`` plans without writing.

Junction tables not yet covered (``sqlatable_user``, ``rls_filter_*``,
``report_schedule_user``) are typically small in production and
require additional parent seeding (RLS filters, report schedules)
that wasn't worth the scope here. Adding them is straightforward by
extending ``JUNCTIONS`` and writing the corresponding parent seeder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the stress-test seed script with an optional duplicate-row
injection step, used to measure the empirical cost of the migration's
``_dedupe_by_min_id`` phase.

Usage: after running the normal seed at a given scale, add
``--dirty-duplicates-pct 5`` (or any non-zero value) to inject that
percentage of duplicate ``(fk1, fk2)`` rows into each non-UNIQUE
junction (slice_user, dashboard_user, dashboard_roles —
dashboard_slices is skipped because its UNIQUE constraint, present
both pre- and post-migration, rejects duplicates).

Pre-condition: requires the DB to be at the pre-migration revision
(33d7e0e21daa). The post-migration composite PK rejects duplicates,
so attempting to inject on the upgraded schema errors out.

Empirical result on MySQL @ 10M dashboard_slices + ~2.1M other
junction rows + 105K injected duplicates (5% on the 3 non-UNIQUE
tables):
  Upgrade time: 1m 36s vs clean baseline 1m 37s
  → dedupe cost is within measurement noise; the table-scan that
    the migration already performs dominates whether or not
    duplicates exist.

This empirically confirms what the cost-model predicted: the
``_dedupe_by_min_id`` GROUP BY scan is the dominant cost of that
phase, and the actual per-duplicate DELETE is negligible.

NULL-FK injection deliberately skipped — would require altering the
six non-UNIQUE FK columns from NOT NULL back to nullable (the
migration's downgrade keeps them NOT NULL by design), which adds
per-backend ALTER complexity for a code path that's structurally
identical in cost shape (DELETE WHERE col IS NULL is the same scan
shape as the dedupe scan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mikebridge mikebridge force-pushed the sc-105349-composite-association-pks branch from bc31f72 to 10c36ac Compare May 7, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend risk:breaking-change Issues or PRs that will introduce breaking changes risk:ci-script PR modifies scripts that execute in CI (supply chain risk) risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants